// Copyright 2016 Pants project contributors (see CONTRIBUTORS.md). // Licensed under the Apache License, Version 2.0 (see LICENSE). package com.twitter.intellij.pants.file; import com.google.common.hash.HashCode; import com.google.common.hash.HashFunction; import com.google.common.hash.Hashing; import com.intellij.notification.EventLog; import com.intellij.notification.Notification; import com.intellij.notification.NotificationListener; import com.intellij.notification.NotificationType; import com.intellij.notification.Notifications; import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.project.Project; import com.intellij.openapi.roots.ProjectRootManager; import com.intellij.openapi.vfs.LocalFileSystem; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.openapi.vfs.VirtualFileCopyEvent; import com.intellij.openapi.vfs.VirtualFileEvent; import com.intellij.openapi.vfs.VirtualFileListener; import com.intellij.openapi.vfs.VirtualFileManager; import com.intellij.openapi.vfs.VirtualFileMoveEvent; import com.intellij.openapi.vfs.VirtualFilePropertyEvent; import com.twitter.intellij.pants.PantsBundle; import com.twitter.intellij.pants.metrics.PantsExternalMetricsListenerManager; import com.twitter.intellij.pants.settings.PantsSettings; import com.twitter.intellij.pants.util.PantsConstants; import com.twitter.intellij.pants.util.PantsUtil; import org.jetbrains.annotations.NotNull; import javax.swing.event.HyperlinkEvent; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Paths; import java.time.LocalTime; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.jar.Manifest; import static java.time.temporal.ChronoUnit.MILLIS; // FIXME: Change in pants.ini, `./pants clean-all` is not tracked currently. public class FileChangeTracker { private static final Logger LOG = Logger.getInstance(FileChangeTracker.class); public static final String HREF_REFRESH = "refresh"; public static final String REFRESH_PANTS_PROJECT_DISPLAY = "Refresh Pants Project"; private static FileChangeTracker instance = new FileChangeTracker(); // One to one relation between VirtualFileListener and Project, // so whenever a VirtualFileListener is triggered, we know which Project is affected. private static ConcurrentHashMap<VirtualFileListener, Project> listenToProjectMap = new ConcurrentHashMap<>(); // Maps from Project to <myIsDirty, lastCompileSnapshot> private static ConcurrentHashMap<Project, ProjectState> projectStates = new ConcurrentHashMap<>(); /** * Keep certain states about the current project. */ private static class ProjectState { boolean myIsDirty; LocalTime myLastModifiedTime; Optional<CompileSnapshot> myLastCompileSnapshot; public ProjectState( boolean isDirty, LocalTime lastModified, Optional<CompileSnapshot> lastCompileSnapshot ) { myIsDirty = isDirty; myLastModifiedTime = lastModified; myLastCompileSnapshot = lastCompileSnapshot; } public boolean isDirty() { return myIsDirty; } public void setDirty(boolean dirty) { myIsDirty = dirty; } public LocalTime getLastModifiedTime() { return myLastModifiedTime; } public void setLastModifiedTime(LocalTime lastModifiedTime) { this.myLastModifiedTime = lastModifiedTime; } public Optional<CompileSnapshot> getLastCompileSnapshot() { return myLastCompileSnapshot; } public void setLastCompileSnapshot(Optional<CompileSnapshot> lastCompileSnapshot) { this.myLastCompileSnapshot = lastCompileSnapshot; } } public FileChangeTracker getInstance() { return instance; } private static void markDirty(@NotNull VirtualFile file, @NotNull VirtualFileListener listener) { Project project = listenToProjectMap.get(listener); boolean inProject = ProjectRootManager.getInstance(project).getFileIndex().getContentRootForFile(file) != null; LOG.debug(String.format("Changed: %s. In project: %s", file.getPath(), inProject)); if (inProject) { markDirty(project); notifyProjectRefreshIfNecessary(file, project); } } private static boolean hasExistingRefreshNotification(Project project) { ArrayList<Notification> notifications = EventLog.getLogModel(project).getNotifications(); return notifications.stream().anyMatch(s -> s.getContent().contains(REFRESH_PANTS_PROJECT_DISPLAY)); } /** * Template came from maven plugin: * https://github.com/JetBrains/intellij-community/blob/b5d046018b9a82fccd86bc9c1f1da2e28068440a/plugins/maven/src/main/java/org/jetbrains/idea/maven/utils/MavenImportNotifier.java#L92-L108 */ private static void notifyProjectRefreshIfNecessary(@NotNull VirtualFile file, final Project project) { // If there is standing refresh notification, do not proceed to issue another notification. if (hasExistingRefreshNotification(project)) { return; } NotificationListener.Adapter refreshAction = new NotificationListener.Adapter() { @Override protected void hyperlinkActivated(@NotNull Notification notification, @NotNull HyperlinkEvent event) { if (HREF_REFRESH.equals(event.getDescription())) { PantsUtil.refreshAllProjects(project); } notification.expire(); } }; if (PantsUtil.isBUILDFileName(file.getName())) { Notification myNotification = new Notification( PantsConstants.PANTS, PantsBundle.message("pants.project.build.files.changed"), "<a href='" + HREF_REFRESH + "'>" + REFRESH_PANTS_PROJECT_DISPLAY + "</a> ", NotificationType.INFORMATION, refreshAction ); Notifications.Bus.notify(myNotification, project); } } public static void markDirty(@NotNull Project project) { final boolean isDirty = true; projectStates.put(project, new ProjectState(isDirty, LocalTime.now(), Optional.empty())); } public static void addManifestJarIntoSnapshot(@NotNull Project project) { Optional<CompileSnapshot> snapshot = projectStates.get(project).getLastCompileSnapshot(); if (!snapshot.isPresent()) { return; } Optional<VirtualFile> manifestJar = PantsUtil.findProjectManifestJar(project); snapshot.get().setManifestJarHash(fileHash(manifestJar)); } public static Optional<String> fileHash(Optional<VirtualFile> vf) { if (!vf.isPresent()) { return Optional.empty(); } HashFunction hf = Hashing.md5(); try { byte[] bytes = Files.readAllBytes(Paths.get(vf.get().getPath())); HashCode hash = hf.newHasher().putBytes(bytes).hash(); return Optional.of(hash.toString()); } catch (IOException e) { e.printStackTrace(); return Optional.empty(); } } /** * Determine whether a project should be recompiled given targets to compile and PantsSettings * by comparing with the last one. * <p> * It assumes the compilation is going to work, if not, `markDirty` should be called explicitly upon failure. * <p> * Side effect: if the answer is yes (true), it will also reset the project state. * * @param project: project under question. * @param targetAddresses: target addresses for this compile. * @return true if anything in the project has changed or the current `CompileSnapshot` does not match with * the previous one. */ public static boolean shouldRecompileThenReset(@NotNull Project project, @NotNull Set<String> targetAddresses) { PantsSettings settings = PantsSettings.getInstance(project); ProjectState lastRecordedState = projectStates.get(project); CompileSnapshot snapshot = new CompileSnapshot(targetAddresses, settings, PantsUtil.findProjectManifestJar(project)); // there is no previous record. if (lastRecordedState == null) { resetProjectState(project, snapshot); return true; } if (lastRecordedState.isDirty()) { long betweenMilliSec = MILLIS.between(lastRecordedState.getLastModifiedTime(), LocalTime.now()); PantsExternalMetricsListenerManager.getInstance().logDurationBeforePantsCompile(betweenMilliSec); } Optional<CompileSnapshot> previousSnapshot = lastRecordedState.getLastCompileSnapshot(); if ( // Recompile if project is in incremental mode, so there is no way to keep track of the all changes // in the transitive graph. settings.isEnableIncrementalImport() // Recompile if project is dirty or there is no previous record. || (lastRecordedState.isDirty()) // Recompile if there is no previous record. || !previousSnapshot.isPresent() // Recompile if current snapshot is different from previous one. // Then reset snapshot. || (!snapshot.equals(previousSnapshot.get())) // if manifest is not valid any more. || !isManifestJarValid(project) ) { resetProjectState(project, snapshot); return true; } return false; } /** * Check whether all the class path entries in the manifest are valid. * * @param project current project. * @return true iff the manifest jar is valid. */ private static boolean isManifestJarValid(@NotNull Project project) { Optional<VirtualFile> manifestJar = PantsUtil.findProjectManifestJar(project); if (!manifestJar.isPresent()) { return false; } VirtualFile file = manifestJar.get(); if (!new File(file.getPath()).exists()) { return false; } try { VirtualFile manifestInJar = VirtualFileManager.getInstance().refreshAndFindFileByUrl("jar://" + file.getPath() + "!/META-INF/MANIFEST.MF"); if (manifestInJar == null) { return false; } Manifest manifest = new Manifest(manifestInJar.getInputStream()); List<String> relPaths = PantsUtil.parseCmdParameters(Optional.of(manifest.getMainAttributes().getValue("Class-Path"))); for (String path : relPaths) { // All rel paths in META-INF/MANIFEST.MF is relative to the jar directory if (!new File(file.getParent().getPath(), path).exists()) { return false; } } return true; } catch (IOException e) { e.printStackTrace(); return false; } } /** * Reset project to be clean. */ private static void resetProjectState(@NotNull Project project, CompileSnapshot snapshot) { boolean isDirty = false; projectStates.put(project, new ProjectState(isDirty, LocalTime.now(), Optional.of(snapshot))); } public static void registerProject(@NotNull Project project) { VirtualFileListener listener = getNewListener(); LocalFileSystem.getInstance().addVirtualFileListener(listener); listenToProjectMap.put(listener, project); } public static void unregisterProject(@NotNull Project project) { projectStates.remove(project); // Remove the listener for the project. listenToProjectMap.entrySet().stream() .filter(s -> s.getValue() == project) .findFirst() .ifPresent(x -> { VirtualFileListener listener = x.getKey(); listenToProjectMap.remove(listener); LocalFileSystem.getInstance().removeVirtualFileListener(listener); }); } private static VirtualFileListener getNewListener() { return new VirtualFileListener() { @Override public void propertyChanged(@NotNull VirtualFilePropertyEvent event) { FileChangeTracker.markDirty(event.getFile(), this); } @Override public void contentsChanged(@NotNull VirtualFileEvent event) { FileChangeTracker.markDirty(event.getFile(), this); } @Override public void fileCreated(@NotNull VirtualFileEvent event) { FileChangeTracker.markDirty(event.getFile(), this); } @Override public void fileDeleted(@NotNull VirtualFileEvent event) { FileChangeTracker.markDirty(event.getFile(), this); } @Override public void fileMoved(@NotNull VirtualFileMoveEvent event) { FileChangeTracker.markDirty(event.getFile(), this); } @Override public void fileCopied(@NotNull VirtualFileCopyEvent event) { FileChangeTracker.markDirty(event.getFile(), this); } @Override public void beforePropertyChange(@NotNull VirtualFilePropertyEvent event) { } @Override public void beforeContentsChange(@NotNull VirtualFileEvent event) { } @Override public void beforeFileDeletion(@NotNull VirtualFileEvent event) { } @Override public void beforeFileMovement(@NotNull VirtualFileMoveEvent event) { } }; } /** * `CompileSnapshot` defines a moment with `PantsSettings`, set of target addresses used to compile, * and compiled manifest.jar. */ private static class CompileSnapshot { Set<String> myTargetAddresses; Optional<String> myManifestJarHash; PantsSettings myPantsSettings; private void setManifestJarHash(Optional<String> manifestJarHash) { myManifestJarHash = manifestJarHash; } private CompileSnapshot(Set<String> targetAddresses, PantsSettings pantsSettings, Optional<VirtualFile> manifestJar) { myTargetAddresses = Collections.unmodifiableSet(targetAddresses); myPantsSettings = PantsSettings.copy(pantsSettings); myManifestJarHash = fileHash(manifestJar); } @Override public boolean equals(Object obj) { if (obj == null) { return false; } if (getClass() != obj.getClass()) { return false; } CompileSnapshot other = (CompileSnapshot) obj; return Objects.equals(this.myPantsSettings, other.myPantsSettings) && Objects.equals(this.myManifestJarHash, other.myManifestJarHash) && Objects.equals(this.myTargetAddresses, other.myTargetAddresses); } } }